iT邦幫忙

2026 iThome 鐵人賽

DAY 3
0
AI Engineering

Learning SRE for the AI Era:從 SRE Lab 到 Production AI Reliability系列 第 16 篇

Day 12(下)|AI Workflow Failure Modes:模型有回話,不等於工作完成

  • 分享至 

  • xImage
  •  

GitHub:darkstar1227/learning-sre-for-ai-era

**一句話先講完:**同一個 request 能不能被安全交付,不能只看 HTTP 有沒有回 200;要把 retrieval、model、tool、parser、agent 五個節點各自的失敗狀態,接回 metrics、logs、traces,並決定什麼時候該叫醒值班的人、什麼時候只該進 evaluation queue。

上篇把 workflow 拆成 retrieve、model_output、tool_call、response、agent_loop 五個可失敗的節點,用 empty retrieval、LLM timeout、tool schema mismatch、parser error、agent loop 五種 failure mode 逐一說明對外安全狀態該怎麼設計,並在 Day12/DIY/ 用一組 deterministic fixture 驗證這些安全反應確實存在。這一篇接著把這些節點的結果,串回可觀測性系統與值班流程。

⑧ 把結果接回 metrics、logs 與 traces

fixture 能回答「某個輸入應落在哪一種安全狀態」。

它不能取代 production 的觀測。

真正的 request 經過 provider、retrieval index 與 tool service 時,還需要三種互補證據。

Metrics:哪一類 failure 正在增加?
Logs:這個 request 的決策與錯誤細節是什麼?
Traces:哪個節點先慢下來,後面又發生了什麼?

不要把 trace_id 放進 Prometheus label。

它是高 cardinality 的 request 識別字,適合留在 log 與 trace,不適合用來建立長期 time series。

metric 只保留可聚合的低 cardinality 維度:

ai_workflow_outcomes_total{
  workflow="policy_qa",
  node="retrieve",
  status="insufficient_context"
}

ai_workflow_step_duration_seconds{
  workflow="policy_qa",
  node="model_output",
  provider="primary"
}

對應的 structured log 才保留 request 層級關聯:

{
  "event": "workflow_completed",
  "request_id": "req_...",
  "trace_id": "trace_...",
  "node": "response",
  "status": "quality_failed",
  "retrieval_doc_count": 1,
  "retrieval_version": "policy-index-2026-09",
  "model_name": "selected-model",
  "prompt_version": "policy-v4"
}

這裡刻意沒有 prompt、完整文件、完整 user question 或 tool credential。

可觀測性不是免責的資料收集器。

先定義誰可讀取、保存多久、如何遮罩,才把 metadata 送到 trace 或 log backend。

三種證據為什麼要互補,而不是三選一

Day 2 已經建過 Prometheus + Loki + Tempo 這套組合,這裡值得把「為什麼是三種」講清楚,因為在 AI workflow 裡,這三者分工比傳統 web service 更明顯:

Metric 回答「量」的問題:
  最近 15 分鐘,insufficient_context 的比例是不是比平常高?
  → 觸發你去看,但不會告訴你為什麼

Log 回答「這一筆」的問題:
  這個 request_id 走到 quality_failed,retrieval_doc_count 是多少?
  index_version 是舊的還是新的?
  → 給你單一事件的決策細節

Trace 回答「順序與延遲」的問題:
  retrieve 花了多久?model_output 花了多久?
  是不是某個節點突然變慢,把後面的 deadline 都吃光了?
  → 給你節點之間的時間關係

三者串起來的典型排查流程長這樣:先在 metric 上看到 ai_workflow_outcomes_total{status="quality_failed"} 這條線突然往上跳,這一步只能告訴你「有異常」,不能告訴你「為什麼」;接著你用同一個時間窗篩出對應的 structured log,看到一批 request 的 retrieval_version 全部指向同一個舊版 index;最後你抓其中一個 trace_id 去 Tempo 裡看完整的 span,確認是 index 重建作業(reindex job)跟 retrieval 服務重疊執行,導致那段時間查到的是半新半舊的資料。這條路徑走完,你才有足夠的證據寫進 incident timeline,而不是猜測。少了任何一層,這條路徑都會斷:只有 metric,你知道「壞了」但不知道「哪裡壞」;只有 log,你能查單筆但看不出趨勢;只有 trace,你能看單次呼叫的細節但抓不到「這是不是普遍現象」。

⑨ 同一個 dashboard,不能混成一條成功率

以下四個結果都可能讓 HTTP 200,卻不該被同一條 success rate 掩蓋:

technical status quality status safety status task status 解讀
success passed passed completed 可交付的正常結果
success failed passed not_completed 模型有回覆,但引用或事實檢查失敗
success passed blocked not_completed 內容可讀,但 action 不被授權
timeout unknown passed not_completed workflow 在品質檢查前停止

Day 8 的三條軸線在這裡仍然適用。

不要做一個平均所有結果的「AI Health Score」。

如果 quality_failed 上升,先看 index、prompt 或 model release。

如果 tool_validation_failed 上升,先看 tool schema、呼叫端版本或 permission policy。

如果 llm_timeout 上升,先看 provider latency、deadline、queue 與 retry。

相同的紅色圖表,不一定有相同的值班動作。

一個「平均成功率」會怎麼騙過你的具體例子

假設某個 policy QA 系統這週的技術層 success rate 是 99.2%,看起來相當健康。但如果把上面那張四行表格的四種組合分開統計,可能長這樣:

success + quality passed + safety passed         → 91.0%(真正可交付)
success + quality failed(citation 不存在)        →  6.5%(Air Canada、MyCity 這類事故的溫床)
success + safety blocked(tool 未授權)            →  1.7%(本來就該被擋下來,不是異常)
timeout(品質檢查前中止)                          →  0.8%(technical failure)

如果 dashboard 只顯示「success rate 99.2%」,這串數字會讓值班者以為系統幾乎完美——但其中 6.5% 的請求,使用者拿到的是格式正常、語氣自信,內容卻可能是編造或過期資訊的回答。這正是本篇反覆強調的重點:「success」在這裡只是 HTTP 傳輸層的判定,不是使用者任務是否被正確完成的判定。 Air Canada 的聊天機器人案例(正常回應、內容卻是編造的優惠政策)與②段 MyCity 的錯誤法律建議,都落在 success + quality failed 這一行——都不會出現在「success rate 99.2%」這個單一數字裡,只有拆開技術、品質、安全、任務四個維度分別統計,才看得見它們。

把這四個維度變成實際的 PromQL

光說「要分開統計」還太抽象,實務上通常會在 metrics 裡替每個 response 打上多個 label,讓 Prometheus 可以分別查詢:

workflow_requests_total.labels(
    technical_status="success",
    quality_status="failed",
    safety_status="passed",
).inc()

有了這組 label,dashboard 上除了原本那條「整體 success rate」的曲線,還可以疊上一條專門盯 quality_status="failed" 的曲線:

sum(rate(workflow_requests_total{technical_status="success"}[5m]))
  /
sum(rate(workflow_requests_total[5m]))
→ 這是傳統的 success rate,Air Canada、MyCity 事故發生當下這條線幾乎不會掉

sum(rate(workflow_requests_total{quality_status="failed"}[5m]))
  /
sum(rate(workflow_requests_total{technical_status="success"}[5m]))
→ 這條線才是「回應正常送達,但內容不可信」的比例,理論上事故發生時它應該先動

這裡要小心一個 cardinality 陷阱:technical_status、quality_status、safety_status 這三個欄位本身值域很小(各自只有個位數種狀態),可以放心當 label;但千萬不要把 citation_id、document_id 這種高基數欄位也塞進同一組 label,那樣會讓 Prometheus 的時間序列數量爆炸,跟 Day 2 提過的「label 只放低基數欄位」原則是同一件事,只是這次的違規對象換成了 workflow 層的語義欄位,而不是傳統的 user_id。

⑩ 反例:看見 timeout 後立刻 fallback,也可能錯

假設 primary model timeout,系統立即切到 fallback model。

這個策略不能直接判定對錯。

先檢查 fallback 是否仍在 request deadline 內,是否有相同 tool calling contract,是否受相同資料治理限制,以及是否會改變 task 的品質條件。

不好的 fallback:
primary timeout
  → 無視 deadline 轉送另一個 provider
  → fallback 產生無法解析的 action
  → 仍回 HTTP 200

較安全的 fallback:
primary timeout
  → 確認剩餘 deadline 與 retry budget
  → 選擇相容 model route,或停止
  → 重新套用 parser、citation、tool authorization checks
  → 記錄 fallback_triggered 與最終 task status

fallback 是 Day 13 的備援問題。

它不會取消 Day 12 的 contract。

不論選到哪個 provider,empty retrieval 仍不能變成臆測答案,未授權 action 仍不能執行。

把「較安全的 fallback」寫成可以檢查的判斷式

上面的文字流程可以收斂成一個明確的守門函式,逼自己在寫 fallback 邏輯時,把每一個檢查項目都列出來,而不是憑直覺判斷「應該可以切吧」:

def may_fallback(
    *,
    remaining_deadline_ms: int,
    fallback_min_latency_ms: int,
    primary_tool_contract_version: str,
    fallback_tool_contract_version: str,
    primary_data_region: str,
    fallback_data_region: str,
) -> tuple[bool, str]:
    if remaining_deadline_ms <= fallback_min_latency_ms:
        return False, "fallback would not fit remaining deadline"
    if primary_tool_contract_version != fallback_tool_contract_version:
        return False, "fallback model does not share tool calling contract"
    if primary_data_region != fallback_data_region:
        return False, "fallback would cross data residency boundary"
    return True, "fallback allowed"

這段程式碼故意把「資料主權(data residency)」也放進檢查項目,因為這是實務上很容易被忽略的一項:如果 primary provider 的資料處理地區跟 fallback provider 不同,切換 fallback 可能讓原本符合某個地區法規(例如歐盟資料不出境)的請求,突然違反那個法規——即使功能上完全正常,這仍然是一種需要被擋下來的 failure mode,只是它的風險不在「答案錯不錯」,而在「這次呼叫本身合不合規」。

四個檢查項各自對應哪一種「不好的 fallback」

把 may_fallback 的四個回傳條件,逐一對回本節開頭那段「不好的 fallback」流程圖,會更清楚每個檢查在防的是哪一種具體失敗:

may_fallback 檢查項 對應的壞情境 如果不檢查會發生什麼
remaining_deadline_ms <= fallback_min_latency_ms 「無視 deadline 轉送另一個 provider」 fallback 呼叫本身就需要更長時間建立連線(新的 provider、新的認證),結果反而讓使用者等得更久
primary_tool_contract_version != fallback_tool_contract_version 「fallback 產生無法解析的 action」 fallback model 用不同的 schema 版本輸出 tool call,validator 拿舊版 schema 去驗,直接判定格式錯誤,或更糟——validator 也跟著切到寬鬆模式,讓不該通過的 action 通過
primary_data_region != fallback_data_region 隱藏在「仍回 HTTP 200」底下、不會反映在錯誤訊息裡的合規風險 技術上請求正常完成,沒有任何錯誤訊息,違規卻已經發生,通常要等到稽核或法規檢查才會被發現
三項都通過才回傳 True 「較安全的 fallback」流程圖裡的第二步 沒有這一步,primary timeout 幾乎必然直接觸發切換,中間所有前提條件都被跳過

這張表格也回答了一個容易被問到的問題:既然 fallback 是 Day 13 要談的備援主題,為什麼 Day 12 要先寫這段程式碼?答案是,may_fallback 檢查的四個條件全部屬於 Day 12 關心的 contract 完整性(deadline、schema、資料邊界),Day 13 要談的是「要不要有 fallback、fallback 的可用性怎麼設計」這種基礎設施層的問題——兩者處理的是同一個決策的不同面向,前者決定「這次切換安不安全」,後者決定「系統有沒有東西可以切」。

⑪ 用 failure card 讓 incident review 有共同語言

每一個新的 workflow node,在進 production 前可以先寫一張卡。

Node:tool_call
Failure:schema_version 不相容
User-visible result:無法完成操作,未執行變更
Machine-readable status:tool_validation_failed
Evidence:trace_id、tool name、expected schema version、received schema version
Safe action:拒絕呼叫,必要時轉人工
Retry policy:不 retry malformed action
Owner:tool integration team
Regression fixture:tool_schema_mismatch

這張卡很短,但能迫使團隊在 incident 前決定責任。

它也避免兩種常見的事後爭論。

第一種是「模型錯了,應該不是我們的問題」。

第二種是「有 log,為什麼還查不到誰執行了什麼」。

模型輸出不可靠是已知條件;系統如何限制、驗證與記錄它,才是工程責任。

再寫一張卡,對照 empty retrieval 這個節點

同樣的格式套用在 retrieve 節點的 failure,可以看出這張卡的結構如何適應不同 failure mode:

Node:retrieve
Failure:retrieval 最高相關性分數低於門檻
User-visible result:告知目前找不到足夠資料,並提供轉人工管道
Machine-readable status:insufficient_context
Evidence:trace_id、retrieval_doc_count、retrieval_top_score、retrieval_version
Safe action:不呼叫 model 生成具體結論
Retry policy:同一份未變更的 index 不需要重試;index 更新後可視為新請求
Owner:retrieval / knowledge base team
Regression fixture:empty_retrieval、stale_index

兩張卡放在一起看,會發現「Owner」這一欄特別關鍵。tool_call 的責任在整合團隊,retrieve 的責任在知識庫團隊——如果沒有先把這個分工寫清楚,事故發生時很容易變成互踢皮球:整合團隊說「模型輸出的 action 格式沒問題,是文件本身就是舊的」,知識庫團隊說「index 更新是照排程跑的,沒有人告訴我們這次更新影響了哪些查詢」。failure card 把這種各說各話提前攤在檯面上,逼團隊在事故發生前就決定好邊界。

一張卡在 incident review 會議裡實際怎麼被使用

假設 insufficient_context 的觸發率某週突然從平常的 2% 跳到 11%。有了上面那張 retrieve node 的 failure card,會議討論可以跳過「這是誰的問題」的開場,直接照卡片欄位走:對照 Evidence 拉出 retrieval_top_score 分佈,發現整體偏低;對照 Owner 找知識庫團隊,追查出三天前一批文件被重新分類、舊 index 還沒針對新分類重跑;對照 Safe action 確認「不呼叫 model 生成具體結論」這條防線有被正確執行,代表使用者沒收到編造答案,降低了急迫性但仍需修根因;對照 Regression fixture,確認這次對應的正是既有的 stale_index,不需要新增 fixture,只需要在 index 更新流程補一道檢查。

整場討論沒有花時間重新定義「什麼算故障」「這是誰的鍋」,因為這些問題在卡片寫好的當下就已經有答案。真正花時間討論的,是卡片上不該預先寫死的部分——這次具體的根因、以及要不要調整流程本身。這正是 failure card 存在的意義:把能事先講清楚的部分講清楚,把值班者的注意力留給真正需要臨場判斷的部分。

⑫ 何時該 page,何時只進 evaluation queue?

不要讓每一筆 quality_failed 叫醒值班者。

單一 request 的引用不符,通常應保存 fixture candidate、標記 dataset 或進人工複核 queue。

以下情況才比較接近 operational alert:

短時間內 llm_timeout 比率跨過服務定義的門檻
同一 deployment 後 parser_error 突然升高
tool_validation_failed 在 schema rollout 後集中出現
agent_loop_limit 導致 token 或 cost 急升

alert 是要人立刻處理的 service risk。

evaluation 是要判斷系統品質是否退化的回饋迴路。

human review 則處理高影響、低確定性或需要業務判斷的個案。

三者都重要,混在同一個 channel 通常只會讓人開始靜音。

反面案例:升級路徑存在,卻一直沒被走過

②段提過的紐約市 MyCity 聊天機器人,後續處理剛好示範了這三條路徑沒被正確使用會是什麼樣子。同一個 deployment 持續產生大量涉及勞動法、居住權的 quality_failed 結果,理論上已經滿足「operational alert」的條件。但市府實際的回應,既不是暫停高風險類別的回答(alert),也沒有讓法規問題轉真人複核(human review),而是停在「先加一行免責聲明,之後再慢慢修」。

這不是說 MyCity 的做法完全沒道理——政府服務要考慮的因素比一般 SaaS 產品複雜,貿然關閉一個公開宣傳過的服務也有政治成本。但這起事故清楚示範了三條路徑沒有被明確區分時的後果:如果一開始就把「涉及現行法規的錯誤建議」列為必須升級的類別,處理的優先順序可能完全不同。三條路徑的區分,本質上是替「這件事有多急」先做好分類,而不是等事情鬧大了才臨時決定。

一個可以實際套用的分流判斷式

把上面的原則寫成程式碼會長這樣,重點不在語法本身,而是這個判斷順序:先問「有沒有立即風險」,再問「有沒有規模訊號」,最後才是「值不值得留給下一輪迭代」。

def triage(event: WorkflowEvent) -> Literal["page", "evaluation_queue", "human_review"]:
    # 第一層:涉及人身安全、法規遵循或財務損失的單一事件,
    # 即使只有一筆,也不能等統計數字累積才處理。
    if event.safety_status == "blocked" and event.risk_tier == "high":
        return "human_review"

    # 第二層:有明確的規模或速率訊號,代表這不是單一個案,
    # 而是系統性問題正在擴散,需要立刻有人介入。
    if event.node == "model_output" and event.recent_timeout_rate > TIMEOUT_ALERT_THRESHOLD:
        return "page"
    if event.node == "tool_call" and event.recent_validation_fail_rate > SCHEMA_ALERT_THRESHOLD:
        return "page"

    # 第三層:其餘的 quality_failed,先進佇列,等累積到一定量
    # 或被標記為高頻模式時,再決定要不要升級。
    return "evaluation_queue"

把 MyCity 的案例套進這個判斷式,「涉及現行法規的錯誤建議」理應在第一層就被攔下——safety_status 應該標記為 blocked(給出可能違法的建議,本質上是一種需要被擋下的高風險輸出),risk_tier 則是 high(勞動法、居住權直接影響使用者的財務與法律處境)。如果分流邏輯有明確把這兩個欄位串起來,第一筆記者發現的錯誤建議就會直接進 human_review,而不是靜靜躺在某個「之後修」的待辦清單裡等下一次迭代。

⑬ DIY 後的限制:仍然沒有被證明的事

即使 fixture verification 通過,以下事項仍是 UNKNOWN,直到你在受控環境用真實整合測試驗證:

provider SDK timeout 是否真的被正確取消
client disconnect 時 downstream work 是否停止
retry 是否會重複造成有副作用的 tool action
trace、log、metric 是否能以 request_id / trace_id 正確關聯
敏感欄位是否在所有 exporter 與 log sink 被遮罩
fallback model 是否與 primary model 維持相同 contract

這不是 DIY 的缺點。

它是證據邊界。

先用 deterministic fixture 固定「預期安全行為」,再以 staging integration test 和 production telemetry 驗證真實 dependency 的行為。兩種證據不能互相冒充。

證據邊界不是自謙用詞

這不是作者謙虛地說「DIY 做得不夠完整」,而是刻意畫出的一條界線。deterministic fixture 能回答的問題形式永遠是「給定這個輸入,系統是否做出了正確的安全反應」;它回答不了「這個輸入在生產環境會不會真的發生」「真實 provider 的 timeout 行為是否跟 mock 出來的一樣」。

這條界線最常被「我們的單元測試都過了,應該沒問題」這句話跨越。單元測試過了,只代表「程式碼在你設想的情境下行為正確」,從未代表「你設想的情境涵蓋了生產環境會出現的所有情境」——④段 Chevrolet 的聊天機器人事故就是提醒:如果測試案例裡從來沒出現過「使用者傳一句話改寫系統指令」這種輸入,再多單元測試也不會發現這個缺口。fixture 只能證明「已知情境下的安全行為」,真實世界的未知情境,永遠需要 staging 整合測試與 production telemetry 來補上。

兩種證據各自能回答的問題,寫成對照表

把「fixture 能證明什麼」跟「staging/production 才能證明什麼」放在一起看,邊界會更具體:

問題 deterministic fixture staging 整合測試 / production telemetry
給定這組輸入,系統有沒有回傳正確的 status? 能回答 能回答,但速度慢、成本高
provider 真實 timeout 分佈長怎樣? 不能回答(timeout 是 mock 出來的) 能回答
這個情境在生產環境多久發生一次? 不能回答 能回答
使用者會用什麼方式繞過系統設計者沒想到的路徑? 不能回答(fixture 只涵蓋已知情境) 部分能回答,仍需持續觀察真實流量
修好一個 bug 之後,會不會又壞掉? 能回答(regression fixture 的核心用途) 通常太慢,不適合當日常防線

這張表格也解釋了為什麼本篇的 DIY 選擇只做 fixture,而不強求「完整」——一個沒有真實 provider 帳號、沒有 staging 環境的讀者,能在自己電腦上誠實做到的部分,就是表格左欄那幾項;右欄那幾項需要的是團隊在自己的生產環境累積,任何文章的 DIY 都沒辦法代勞。

⑭ 把 failure contract 寫成程式碼前,先決定對外語意

status 欄位不是內部例外名稱的轉錄。

它是 API 對 client、客服、dashboard 與後續 workflow 的共同語言。

如果 API 把所有錯誤都映射成 500,呼叫端只能重試。

如果 API 把所有非技術失敗都藏進 200,呼叫端又可能把不可交付的結果當成功。

先決定你的 contract 要讓 client 知道什麼。

再決定 HTTP code、response body 與 observability event 要怎麼表達。

以下範例將「HTTP transport 是否正常」和「任務能否交付」拆開:

from dataclasses import asdict, dataclass
from typing import Literal

TaskStatus = Literal["completed", "not_completed", "requires_human_review"]
WorkflowStatus = Literal["success", "degraded", "failed"]

@dataclass(frozen=True)
class AskResult:
    request_id: str
    trace_id: str
    workflow_status: WorkflowStatus
    task_status: TaskStatus
    status_code: str
    answer: str | None
    user_message: str
    retryable: bool

def insufficient_context(request_id: str, trace_id: str) -> dict:
    result = AskResult(
        request_id=request_id,
        trace_id=trace_id,
        workflow_status="degraded",
        task_status="not_completed",
        status_code="insufficient_context",
        answer=None,
        user_message="目前找不到足夠資料,無法確認這個問題。",
        retryable=False,
    )
    return asdict(result)

這裡的 user_message 不必暴露「向量資料庫查詢失敗」或內部 index 名稱。

但它必須清楚告訴使用者:這次沒有完成,系統也沒有假裝知道答案。

retryable 同樣不能從 exception class 直接推論。

空 retrieval 對同一份未變更知識庫重送十次,通常不會突然長出資料。

相反地,短暫網路逾時可能值得在剩餘 deadline 內再試一次。

對外 status 與內部證據要分層

對外 status client 可以做什麼 內部需要保留什麼 不要傳給 client 的內容
insufficient_context 改問法、補文件或轉人工 retrieval count、index version、query class 文件原文、embedding、內部 collection 名稱
llm_timeout 稍後重試;高影響任務可轉人工 provider route、attempt、deadline remaining provider credential、完整 prompt
tool_validation_failed 修正輸入或要求授權 schema version、policy decision、tool name allowlist 全表、授權規則細節
parser_error 由服務安全失敗;不要請使用者猜格式 parser version、raw-output digest、trace link raw model output 中的敏感內容
requires_human_review 等待人工確認 risk reason、approval state、actor 審核人個資或內部風險分數

這個分層能避免兩種反效果。

第一種是為了 debug 把 prompt、文件與錯誤堆疊直接回傳到前端。

第二種是安全到只回「發生錯誤」,導致使用者與客服都不知道下一步。

設計 user_message 時,先想像客服會怎麼用它

這張分層表格常被忽略的一個讀者,是客服團隊,而不只是前端工程師。當使用者帶著「系統說找不到答案」聯絡客服,客服人員手上通常只有這個 user_message,不會有內部 log 的存取權限。如果 user_message 寫得太籠統(例如「發生錯誤,請稍後再試」),客服除了複誦同一句話之外無法多做什麼;如果寫得太技術(例如直接把 retrieval_version: policy-index-2026-09 塞進去),使用者跟客服都看不懂,等於沒說。

比較實用的做法,是讓 user_message 本身帶有明確的下一步動作,而不是單純描述狀態:

不夠好:"系統目前無法回答這個問題。"
夠好的:"目前找不到與這個問題直接相關的內部規定,
        建議改用更具體的關鍵字重新描述,或聯繫 HR 窗口確認。"

差別在於後者把「使用者接下來該做什麼」講清楚了,這件事本身就是本節反覆強調的「contract 是對外的共同語言」——語言不只是「告知狀態」,還包括「告知下一步」。

⑮ 讓 retry policy 有一個能被審查的輸入

retry 常被寫在 SDK wrapper 裡,只有一個 max_retries=3。

這太少資訊了。

每次決定是否重試,至少依賴工作類型、錯誤類別、剩餘時間與副作用風險。

from dataclasses import dataclass
from enum import StrEnum

class FailureClass(StrEnum):
    TRANSIENT = "transient"
    OVERLOAD = "overload"
    INVALID_REQUEST = "invalid_request"
    UNSAFE_TO_REPLAY = "unsafe_to_replay"
    UNKNOWN = "unknown"

@dataclass(frozen=True)
class RetryDecision:
    should_retry: bool
    reason: str

def decide_retry(
    *,
    failure: FailureClass,
    attempt: int,
    max_attempts: int,
    remaining_ms: int,
    is_idempotent: bool,
) -> RetryDecision:
    if not is_idempotent:
        return RetryDecision(False, "operation has side effects")
    if attempt >= max_attempts:
        return RetryDecision(False, "retry budget exhausted")
    if remaining_ms <= 0:
        return RetryDecision(False, "request deadline exhausted")
    if failure in {FailureClass.INVALID_REQUEST, FailureClass.UNSAFE_TO_REPLAY}:
        return RetryDecision(False, "failure will not be corrected by retry")
    if failure in {FailureClass.TRANSIENT, FailureClass.OVERLOAD}:
        return RetryDecision(True, "bounded retry is allowed")
    return RetryDecision(False, "unknown failure fails conservatively")

這段程式沒有幫你選出正確的 max_attempts。

它做的是把原本藏在 except Exception 裡的判斷攤開。

reviewer 現在可以問:建立 ticket 的操作為何被標成 idempotent?OVERLOAD 的等待是否會超過 request deadline?未知錯誤為什麼能重試?

這才是有用的 code review 問題。

backoff 也必須受到 deadline 約束

不要先算出固定的 sleep 時間,再回頭檢查 deadline。

如果剩餘時間只有 100 毫秒,sleep 500 毫秒後再 retry,使用者早已拿不到結果。

示意流程如下:

收到 temporary failure
  ↓
確認 operation 可安全重放?否 → 停止
  ↓ 是
確認 remaining deadline 是否足夠一次 attempt 加上 backoff?否 → 停止
  ↓ 是
計算有 jitter 的有限 backoff
  ↓
記錄 retry_scheduled,再執行下一次 attempt

Google SRE 對 retry 的重點不是「每一層都更努力」,而是避免多層各自重試,把一次失敗放大成大量後端呼叫。Google SRE Book:Addressing Cascading Failures

在這條 workflow,通常由最了解 provider outcome 的 model adapter 負責 retry。

上層 orchestration 應傳入 deadline 與 budget,並讀取結果;它不應再對同一個 provider timeout 疊一組重試。

把常見的 provider 錯誤對應到 FailureClass

decide_retry 好不好用,取決於呼叫端能不能正確把「原始錯誤」分類成 FailureClass 裡的其中一種。這一步經常被跳過,直接把所有例外都丟進 UNKNOWN,結果整個分類系統形同虛設。實務上可以先建一張對照表,讓「這個 provider 錯誤該怎麼分類」變成團隊共識,而不是每個開發者各自猜:

Provider 回應 FailureClass 為什麼這樣分類
HTTP 429 Too Many Requests OVERLOAD 資源暫時不足,等待後有機會成功
HTTP 503 Service Unavailable OVERLOAD 服務端過載或維護中,通常是暫時的
連線逾時、DNS 解析失敗 TRANSIENT 網路層的暫時性問題,與請求內容無關
HTTP 400 Bad Request(schema 不符) INVALID_REQUEST 請求本身有問題,重送不會改變結果
已知會產生副作用的 action(如已送出的付款) UNSAFE_TO_REPLAY 即使技術上可以重送,業務風險不允許
SDK 拋出未分類的例外 UNKNOWN 保守處理,預設不重試,等釐清後再放行

這張表格最後一列刻意選擇「保守」而不是「樂觀」:遇到不認識的錯誤就假設它不該被重試,等有人實際排查、確認這類錯誤確實安全可重試之後,再把它挪到 TRANSIENT 或 OVERLOAD。反過來設計——遇到不認識的錯誤預設可以重試——聽起來比較「不會漏掉真正該重試的情況」,但代價是任何新出現、還沒被分類的錯誤,都可能被無限制地重送,這正是前面 retry storm 案例裡實際發生的事。

⑯ 讀者實作:用一組 fixture 檢查「停止」真的發生

Day 12 的練習不要求你模擬真實 provider outage。

先用 deterministic fixture 確認安全邊界,成本低,也不會把測試流量打到外部服務。

請由讀者自行操作;以下指令沒有在本次撰寫中執行。

先進入 Day 12 的獨立專案:

cd Day12/DIY
uv sync
uv run python scripts/verify_fixtures.py

執行前,為每個 fixture 寫下你預期會發生的事。

不要只看 process exit code。

Fixture 觸發輸入 預期可見結果 必須證明的停止行為
empty_retrieval 空文件集合 insufficient_context 不呼叫 model,不產生 answer
llm_timeout fake model timeout llm_timeout 或受控降級 attempt 不超過上限,deadline 不被穿透
tool_schema_mismatch 缺少必要欄位的 action tool_validation_failed tool executor 沒被呼叫
parser_error 非 JSON 或壞 contract parser_error 不交付 raw output 當 answer
agent_loop_limit 重複相同 tool input agent_loop_limit loop 在上限前結束

如果你的驗證腳本只印出 PASS,請打開 fixture result,確認它至少包含 status、node 與可關聯的 request identifier。

一個只說「通過」的測試,日後很難告訴你是哪個 contract 改壞了。

為什麼「先寫預期、再跑驗證」這個順序很重要

如果先看到綠色的 ✓ All verification checks passed! 才回頭檢查 fixture 內容,大腦很容易把「測試通過」直接等同於「這個 fixture 涵蓋了正確的情境」——但兩件事其實獨立。一個 fixture 可能結構完整、status 合法、也順利通過驗證,驗證的情境本身卻可能是錯的(例如 insufficient_context 的門檻設得太寬鬆,導致明明該觸發卻沒觸發)。先寫下預期,再對照實際結果,才能檢查「這個 fixture 有沒有測到我以為它在測的東西」,而不只是「有沒有跑起來」——工具能保證斷言有沒有成立,不能保證斷言本身有沒有抓對問題,後者永遠需要人在跑測試前先想清楚。

手動檢查的最小問題集

跑完後,逐條回答:

這個 failure 是 technical、quality、safety,還是 task completion 問題?
client 會知道這次沒有完成嗎?
下一次自動重試是否安全?
哪個 node 產生了決策?
trace 與 log 能否以 request_id / trace_id 關聯?
這個 fixture 是否漏掉了某個有副作用的 action?

前四題應從 response contract 與 fixture assertion 就能回答。

最後兩題通常需要觀察資料模型與整合環境,不能靠單元測試假裝已驗證。

一個常見的失敗示範

def unsafe_handle_timeout(client, payload):
    for _ in range(10):
        try:
            return client.run(payload)
        except TimeoutError:
            continue
    return {"ok": False}

這段程式看起來很努力,實際上缺少 deadline、backoff、錯誤分類、idempotency 與 evidence。

它也沒有告訴呼叫端:是 timeout、預算耗盡,還是 action 可能已經被對方接受。

最糟的情況不是程式最後回 False。

是第九次 request 已經建立 ticket,第十次 timeout 又讓系統相信什麼都沒發生。

把這段壞示範改寫成通過 fixture 驗證的版本

拿這段 unsafe_handle_timeout 對照本篇前面 ③ 段的 call_model_with_deadline 和 ⑮ 段的 decide_retry,可以清楚看到差異不在「重試次數」,而在「每次重試前有沒有回答足夠的問題」:

unsafe_handle_timeout 的隱藏假設:
  「只要重試次數夠多,總有一次會成功」
  → 沒有問:這次操作重放安全嗎?
  → 沒有問:使用者的 deadline 還在嗎?
  → 沒有問:這是暫時性錯誤,還是請求本身就有問題?

改寫後的版本應該長這樣:
  收到 TimeoutError
    → 查 is_idempotent:這個 action 是否安全重放?
    → 查 remaining_deadline_ms:使用者還能等多久?
    → 查 attempt vs max_attempts:預算還有嗎?
    → 都通過才 retry,並記錄 retry_scheduled 與 attempt 編號

如果把這個壞示範也寫成一個 fixture(輸入:一個會連續 timeout 十次的 mock client;預期輸出:在第 max_attempts 次之後停止,且回傳的 status 明確標示是 llm_timeout 還是 agent_loop_limit,而不是一個語意不明的 {"ok": False}),跑 verify_fixtures.py 時就能立刻抓到「這段程式碼沒有回傳可追問的證據」這個問題——這正是 fixture-based 驗證真正的價值:它不是用來證明程式碼「能執行」,而是用來檢查程式碼「失敗的方式夠不夠誠實」。

⑰ 從 fixture 到 incident:要保留一條可追問的證據鏈

incident 時最常見的句子是:「我們看到很多 timeout。」

這還不夠。

你需要能把圖上的異常,落到某次 workflow、某個 node、某個版本與一個可重跑案例。

Metric spike
  → deployment / model route / index version
  → trace_id 與 workflow node
  → structured log 的安全摘要
  → 已去識別化 fixture candidate
  → regression test

每一段都應有清楚的 join key。

request_id 可用來串 API log;trace_id 適合串跨 service span;task_id 則在非同步 workflow 或人工複核時維持同一件工作的身分。

不要把完整 prompt 當 join key。

它不穩定,也容易把敏感內容帶進不該看到的系統。

將 incident 變成 regression case 的安全流程

  1. 先凍結 incident 的 metadata:版本、status、node、時間與經過遮罩的錯誤摘要。
  2. 把必要輸入縮成可公開保存的 fixture;移除個資、內部文件與 credential。
  3. 寫出 expected safe outcome,例如「不執行 tool」「回 requires_human_review」。
  4. 修正後先讓 fixture 失敗,再確認修正讓它通過。
  5. 將原始 incident trace 留在權限受控系統;fixture 只保留重現所需的最小資訊。

這個流程刻意不把「重現了模型原文」列成目標。

對非確定性模型而言,安全決策和 contract 通常比逐字輸出更值得固定。

如果你需要檢查 prompt change 是否仍符合品質,Day 21 的 dataset 與 evaluator 會接手;Day 12 先把不安全或不可交付的路徑擋在邊界。

拿 Replit 事故走一次這條流程

回頭套用④段提過的 Replit 資料庫刪除事故:凍結 metadata(node 是 tool_call,status 本該是 tool_validation_failed 卻被放行執行);縮成不含真實 schema 與憑證的 fixture(input 是「查詢回傳空結果+當前 policy 為 code_freeze」);寫出 expected safe outcome(status 應為 tool_validation_failed,安全動作是拒絕執行並標記人工確認);讓這個 fixture 在修正前失敗、修正後(加上 freeze 狀態檢查)通過;原始的完整 trace 留在權限受控系統,fixture 只保留最小重現資訊。

這正是 Day12/DIY/app/fixtures.py 裡 tool_schema_mismatch 這類 fixture 背後真正的設計精神:它們看起來只是幾行 Python dict,但每一個都對應著「某個真實或可預見的情境,一旦被系統誤放行會造成什麼後果」的具體推演。寫 fixture 不是為了湊測試覆蓋率,而是把「我們已經知道這種情況很危險」這件事,變成一個不會被下一次重構意外刪掉的斷言。

⑱ 本日驗收加碼:你能否解釋每一次停止?

完成 DIY 後,追加檢查下列項目。

[ ] 每個 workflow node 有 machine-readable status,而非只有 exception message。
[ ] response body 能區分「可重試」與「需要使用者補資料」。
[ ] retry 決定同時檢查 deadline、attempt budget 與 idempotency。
[ ] timeout retry 有可查的 attempt 與最終停止理由。
[ ] parser 或 validator 失敗時,raw output 不會越過 response boundary。
[ ] tool validation fixture 能證明 executor 沒有被呼叫。
[ ] metric、log、trace 至少可用 request_id 或 trace_id 對回同一 request。
[ ] incident fixture 與原始敏感 trace 有不同資料保存邊界。
[ ] 以上屬於讀者自行執行後才能取得的證據;本文未執行 DIY。

這份清單的目的不是多收集幾個 status。

它要讓 failure 有停止點、使用者結果與調查路徑。

挑兩項出來,說明「打勾」實際代表什麼

清單裡每一項打勾容易,但打勾背後要能回答具體問題,否則這張清單只是自我安慰。以其中兩項為例:

「retry 決定同時檢查 deadline、attempt budget 與 idempotency」這一項打勾,代表你能具體回答:如果現在剩餘 deadline 只有 800ms,下一次 retry 的 backoff 會不會直接被跳過?如果這個 action 帶有副作用(例如已經送出一次扣款請求),重試前有沒有先查 idempotency key 確認上一次呼叫沒有部分成功?如果答不出這兩個具體情境,代表 retry policy 目前只是「重試次數設成 3」的表面實作,而不是本篇 ⑮ 段講的那種真正可審查的判斷式。

「incident fixture 與原始敏感 trace 有不同資料保存邊界」這一項打勾,代表你能具體指出:原始 trace(可能包含使用者個資、完整 prompt 內容)存放在哪個保存週期較短、存取權限較嚴的地方;縮減後的 fixture(拿掉個資、只留下重現 bug 所需的最小欄位)存放在哪個可以進版本控制、供全體工程師查閱的地方。如果兩者其實放在同一個資料夾、用同一組存取權限,代表 ⑰ 段講的「兩種證據不能互相冒充」在你的系統裡還只是一句口號。

清單真正的用途是拿來問自己這種具體問題,而不是拿來湊一份「已完成」的驗收報告。

⑲ 今天先帶走這個判斷式

HTTP 200
≠ workflow completed
≠ answer grounded
≠ tool action authorized
≠ response safe to deliver

AI workflow 的可靠性不是把 timeout 降到零,也不是逼 Retriever 永遠找到文件。更務實的做法是:知道哪個節點失敗,保留足夠證據,然後在不能安全完成時停下來。

下一篇會把問題拉回 infrastructure:當 provider、vector store 或任何依賴失效時,什麼是 SPOF?什麼是多開幾台卻沒有真的 failover?

延伸閱讀


這篇是 Learning SRE for the AI Era 系列的一部分。
Build → Trace → Break → Measure → Evaluate → Recover → Improve.


上一篇
Day 12(上)|AI Workflow Failure Modes:模型有回話,不等於工作完成
下一篇
Day 13(上)|SPOF、Redundancy、Failover:備援不是多開一台就結束
系列文
Learning SRE for the AI Era:從 SRE Lab 到 Production AI Reliability 共 44 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言